Skip to content

fix: prevent iterator invalidation in handleDeadDefender (BUG-02)#3943

Open
berkelmali wants to merge 2 commits into
openfrontio:mainfrom
berkelmali:fix/bug02-dead-defender-iterator
Open

fix: prevent iterator invalidation in handleDeadDefender (BUG-02)#3943
berkelmali wants to merge 2 commits into
openfrontio:mainfrom
berkelmali:fix/bug02-dead-defender-iterator

Conversation

@berkelmali
Copy link
Copy Markdown
Contributor

@berkelmali berkelmali commented May 16, 2026

Resolves #4091

Description:

In AttackExecution.handleDeadDefender(), target.tiles() returns a live Set<TileRef> that is mutated mid-iteration when conquer(tile) is called. Per ECMA-262, deleted entries are silently skipped by the iterator, causing some defender tiles to be missed. Those tiles become orphaned phantom territories owned by a dead player. On the next game tick, downstream systems attempt to call methods on the stale owner reference, throwing an uncaught TypeError that crashes the server.

This PR snapshots the tile collection with Array.from(target.tiles()) before iterating, eliminating iterator invalidation. The inner loop limit is increased from 10 to 100 with an early break when no tiles remain, ensuring large empires are fully absorbed.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

barfires

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ab9e0c00-1f13-4032-8f7d-1c483fe1dc7a

📥 Commits

Reviewing files that changed from the base of the PR and between e3ffe0d and 576544c.

📒 Files selected for processing (2)
  • src/core/execution/AttackExecution.ts
  • tests/HandleDeadDefender.test.ts
💤 Files with no reviewable changes (2)
  • src/core/execution/AttackExecution.ts
  • tests/HandleDeadDefender.test.ts

Walkthrough

The change snapshots the defender's tiles each iteration in handleDeadDefender, runs the snapshot-processing loop up to 100 times with early exit when no tiles remain, and applies existing border/interior conquest logic to each snapshot. New regression tests exercise sweep completion, tile transfer, and timing bounds.

Changes

Post-defender conquest sweep

Layer / File(s) Summary
Conquest sweep iteration logic
src/core/execution/AttackExecution.ts
The loop that processes conquered tiles after a defender dies now snapshots target.tiles() into remainingTiles each iteration, runs up to 100 iterations (early exit when empty), and applies border vs interior ownership handling to tiles in each snapshot.
Regression tests for sweep behavior
tests/HandleDeadDefender.test.ts
Adds test setup and three regression tests that spawn attacker/defender, expand defender territory in scenarios, run ticks until defender dies, assert the defender ends with zero tiles, assert the sweep completes within a bounded time, and assert attacker gains the tiles.

Sequence Diagram(s)

sequenceDiagram
  participant AttackExecution
  participant Target
  participant Tile
  AttackExecution->>Target: snapshot = Array.from(target.tiles())
  loop per snapshot (<=100)
    AttackExecution->>Tile: evaluate border vs interior
    AttackExecution->>Tile: this._owner.conquer(tile)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • evanpelle
  • Celant

🏰 Tiles frozen in arrays, the sweep runs wide and deep,
A hundred gentle passes where old owner secrets sleep.
When snapshots find no tiles, the loop will say goodbye,
Ownership flows onward, no phantom ghosts nearby.
Tests tick the clock and prove the sweep will safely die.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing iterator invalidation in handleDeadDefender by snapshotting the tile collection.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the root cause (iterator invalidation), the fix (snapshotting), and loop adjustments.
Linked Issues check ✅ Passed The PR successfully implements all objectives from issue #4091: snapshots tiles before iteration, increases loop limit to 100 with early break, adds regression tests, and eliminates iterator invalidation.
Out of Scope Changes check ✅ Passed All changes in AttackExecution.ts and the new test file are directly scoped to fixing the iterator invalidation bug and adding regression test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/execution/AttackExecution.ts`:
- Around line 370-373: Add deterministic regression tests for the new sweep
iteration logic in AttackExecution (the loop that calls target.tiles() and does
up to 100 passes). Create tests that (1) mutate the live collection during
iteration to verify safe snapshot behavior by asserting no runtime errors and
that mutated tiles are processed as expected, (2) simulate a larger
dead-defender tile set to confirm full absorption (all defender tiles removed)
after the sweep completes, and (3) verify stable termination when no tiles
remain by asserting the loop exits early (fewer than 100 passes) and no infinite
loop occurs; use a fixed board/state or seeded RNG to ensure determinism and
reference the AttackExecution class and its sweep behavior (the for-loop using
target.tiles()) in test names and assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dadb6691-141d-4eea-93f3-a13671286c01

📥 Commits

Reviewing files that changed from the base of the PR and between b813792 and 2570c30.

📒 Files selected for processing (1)
  • src/core/execution/AttackExecution.ts

Comment on lines +370 to +373
for (let i = 0; i < 100; i++) {
const remainingTiles = Array.from(target.tiles());
if (remainingTiles.length === 0) break;
for (const tile of remainingTiles) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add regression tests for this core simulation change

This fixes a real runtime edge case, but the PR includes no tests for the new sweep behavior (snapshot iteration + 100-pass/early-break flow). Please add deterministic tests that cover at least: (1) live-collection mutation safety, (2) full absorption for larger dead-defender tile sets, and (3) stable termination when no tiles remain.

As per coding guidelines: “All changes to src/core/ must include tests”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/execution/AttackExecution.ts` around lines 370 - 373, Add
deterministic regression tests for the new sweep iteration logic in
AttackExecution (the loop that calls target.tiles() and does up to 100 passes).
Create tests that (1) mutate the live collection during iteration to verify safe
snapshot behavior by asserting no runtime errors and that mutated tiles are
processed as expected, (2) simulate a larger dead-defender tile set to confirm
full absorption (all defender tiles removed) after the sweep completes, and (3)
verify stable termination when no tiles remain by asserting the loop exits early
(fewer than 100 passes) and no infinite loop occurs; use a fixed board/state or
seeded RNG to ensure determinism and reference the AttackExecution class and its
sweep behavior (the for-loop using target.tiles()) in test names and assertions.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management May 16, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/HandleDeadDefender.test.ts`:
- Line 141: Capture the attacker's tile count before performing the sweep/attack
by storing attacker.numTilesOwned() in a variable (e.g., preTiles), then run the
existing sweep/attack call in the test, then get a new count (postTiles =
attacker.numTilesOwned()) and replace the current loose assertion
expect(attacker.numTilesOwned()).toBeGreaterThan(0) with
expect(postTiles).toBeGreaterThan(preTiles) to assert tiles were transferred
from the defender; use variable names like preTiles/postTiles to make intent
clear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f1dcb017-7bd9-43d3-aab8-e5e6a2973fd0

📥 Commits

Reviewing files that changed from the base of the PR and between 2570c30 and b73fe47.

📒 Files selected for processing (1)
  • tests/HandleDeadDefender.test.ts

expect(defender.numTilesOwned()).toBe(0);

// Attacker should have gained tiles
expect(attacker.numTilesOwned()).toBeGreaterThan(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen the assertion to verify tile transfer.

The assertion checks that the attacker has more than zero tiles, but the attacker already owns tiles from spawning (line 48-50). This doesn't prove that tiles were transferred from the defender during the sweep.

Consider capturing the attacker's tile count before the attack and asserting that it increased:

💪 Suggested improvement
+    const attackerTilesBefore = attacker.numTilesOwned();
+
     // Attack and kill the defender
     game.addExecution(new AttackExecution(null, attacker, defender.id(), null));

     for (let i = 0; i < 500; i++) {
       game.executeNextTick();
       if (!defender.isAlive()) break;
     }

     // All defender tiles should now belong to someone other than defender
     expect(defender.numTilesOwned()).toBe(0);

     // Attacker should have gained tiles
-    expect(attacker.numTilesOwned()).toBeGreaterThan(0);
+    expect(attacker.numTilesOwned()).toBeGreaterThan(attackerTilesBefore);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(attacker.numTilesOwned()).toBeGreaterThan(0);
const attackerTilesBefore = attacker.numTilesOwned();
// Attack and kill the defender
game.addExecution(new AttackExecution(null, attacker, defender.id(), null));
for (let i = 0; i < 500; i++) {
game.executeNextTick();
if (!defender.isAlive()) break;
}
// All defender tiles should now belong to someone other than defender
expect(defender.numTilesOwned()).toBe(0);
// Attacker should have gained tiles
expect(attacker.numTilesOwned()).toBeGreaterThan(attackerTilesBefore);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/HandleDeadDefender.test.ts` at line 141, Capture the attacker's tile
count before performing the sweep/attack by storing attacker.numTilesOwned() in
a variable (e.g., preTiles), then run the existing sweep/attack call in the
test, then get a new count (postTiles = attacker.numTilesOwned()) and replace
the current loose assertion expect(attacker.numTilesOwned()).toBeGreaterThan(0)
with expect(postTiles).toBeGreaterThan(preTiles) to assert tiles were
transferred from the defender; use variable names like preTiles/postTiles to
make intent clear.

Snapshot target.tiles() into an array before iterating to prevent
modifying the collection during iteration when conquer() is called.
Also increase loop limit from 10 to 100 with early break on empty
to properly handle large empires.
@berkelmali berkelmali force-pushed the fix/bug02-dead-defender-iterator branch from e3ffe0d to 576544c Compare June 2, 2026 21:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

[Bug / Stability] Dead defender cleanup crashes server via iterator invalidation and ghost territory state corruption

1 participant